page.tsx 48 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051
  1. 'use client';
  2. import { useState, useEffect, useCallback, useRef } from 'react';
  3. import { useParams, useRouter } from 'next/navigation';
  4. import { useAuth } from '@/lib/auth-context';
  5. import { assetsApi, commentsApi, AssetWithComments, Comment, AnnotationData, TranscodeStatus } from '@/lib/api';
  6. import { Avatar } from '@/components/ui/avatar';
  7. import { VideoPlayer } from '@/components/video-player/VideoPlayer';
  8. import { Tool } from '@/components/video-player/AnnotationCanvas';
  9. const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
  10. const MAX_ANNOTATIONS = 10;
  11. const STATUS_CONFIG: Record<string, { label: string; colorClass: string; bgClass: string; dotClass: string }> = {
  12. PENDING_REVIEW: { label: 'Pending Review', colorClass: 'text-warning', bgClass: 'badge-warning', dotClass: 'status-dot-pending' },
  13. CHANGES_REQUESTED: { label: 'Changes Requested', colorClass: 'text-warning', bgClass: 'badge-warning', dotClass: 'status-dot-changes' },
  14. APPROVED: { label: 'Approved', colorClass: 'text-success', bgClass: 'badge-success', dotClass: 'status-dot-approved' },
  15. REJECTED: { label: 'Rejected', colorClass: 'text-danger', bgClass: 'badge-danger', dotClass: 'status-dot-rejected' },
  16. };
  17. const TRANSCODE_CONFIG: Record<TranscodeStatus, { label: string; color: string; bg: string; spinner: boolean }> = {
  18. PENDING: { label: 'Queued', color: '#94A3B8', bg: 'rgba(148,163,184,0.08)', spinner: false },
  19. UPLOADING: { label: 'Uploading video…', color: '#60A5FA', bg: 'rgba(96,165,250,0.08)', spinner: true },
  20. PROCESSING: { label: 'Transcoding…', color: '#A78BFA', bg: 'rgba(167,139,250,0.08)', spinner: true },
  21. COMPLETED: { label: 'Ready', color: '#34D399', bg: 'rgba(52,211,153,0.08)', spinner: false },
  22. FAILED: { label: 'Transcode failed', color: '#F87171', bg: 'rgba(248,113,113,0.08)', spinner: false },
  23. UNSUPPORTED_CODEC: { label: 'Unsupported codec', color: '#FBBF24', bg: 'rgba(251,191,36,0.08)', spinner: false },
  24. };
  25. function formatTimecode(seconds: number, fps: number = 30): string {
  26. if (!seconds || isNaN(seconds)) return '00:00:00:00';
  27. const h = Math.floor(seconds / 3600);
  28. const m = Math.floor((seconds % 3600) / 60);
  29. const s = Math.floor(seconds % 60);
  30. const f = Math.round(seconds * fps) % fps;
  31. return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}:${String(f).padStart(2,'0')}`;
  32. }
  33. export default function ReviewPage() {
  34. const params = useParams();
  35. const assetId = params.assetId as string;
  36. const { token, user } = useAuth();
  37. const router = useRouter();
  38. const [asset, setAsset] = useState<AssetWithComments | null>(null);
  39. const [comments, setComments] = useState<Comment[]>([]);
  40. const [loading, setLoading] = useState(true);
  41. const [currentTime, setCurrentTime] = useState(0);
  42. const [panelWidth, setPanelWidth] = useState(380);
  43. const [showApproval, setShowApproval] = useState(false);
  44. const [updatingStatus, setUpdatingStatus] = useState(false);
  45. const [newComment, setNewComment] = useState('');
  46. const [submitting, setSubmitting] = useState(false);
  47. const [replyTo, setReplyTo] = useState<Comment | null>(null);
  48. const [showResolved, setShowResolved] = useState(false);
  49. // Drawing state — lifted to page level
  50. const [drawMode, setDrawMode] = useState(false);
  51. const [drawTool, setDrawTool] = useState<Tool>('arrow');
  52. const [drawColor, setDrawColor] = useState('#ef4444');
  53. const [pendingStrokes, setPendingStrokes] = useState<AnnotationData[]>([]);
  54. // The comment we're annotating (null = annotating the main video, not a specific comment)
  55. const [annotatingComment, setAnnotatingComment] = useState<Comment | null>(null);
  56. // Portrait / landscape detection
  57. const [isPortrait, setIsPortrait] = useState(false);
  58. useEffect(() => {
  59. const mq = window.matchMedia('(orientation: portrait)');
  60. setIsPortrait(mq.matches);
  61. const handler = (e: MediaQueryListEvent) => setIsPortrait(e.matches);
  62. mq.addEventListener('change', handler);
  63. return () => mq.removeEventListener('change', handler);
  64. }, []);
  65. const isDraggingRef = useRef(false);
  66. const panelRef = useRef<HTMLDivElement>(null);
  67. const resizeStartRef = useRef<{ x: number; w: number } | null>(null);
  68. // Ref to capture strokes for save callback (avoids closure stale value)
  69. const pendingStrokesRef = useRef<AnnotationData[]>([]);
  70. const annotatingCommentRef = useRef<Comment | null>(null);
  71. // Keep refs in sync with state
  72. useEffect(() => { pendingStrokesRef.current = pendingStrokes; }, [pendingStrokes]);
  73. useEffect(() => { annotatingCommentRef.current = annotatingComment; }, [annotatingComment]);
  74. const fps = asset?.fps ?? 30;
  75. // Derive the current user's project role
  76. const currentUserRole = asset?.project.members.find(m => m.user.id === user?.id)?.role;
  77. const isProjectAdmin = currentUserRole === 'ADMIN';
  78. const canComment: boolean | undefined = !!(currentUserRole && currentUserRole !== 'VIEWER');
  79. // ── Poll for transcode progress ───────────────────────────────────────────
  80. const isTranscoding = asset?.transcodeStatus === 'COMPLETED';
  81. const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
  82. useEffect(() => {
  83. if (isTranscoding) {
  84. if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
  85. return;
  86. }
  87. if (pollRef.current) return;
  88. pollRef.current = setInterval(async () => {
  89. if (!token) return;
  90. try {
  91. const { asset: updated } = await assetsApi.getStatus(token, assetId);
  92. setAsset(prev => prev ? { ...prev, ...updated } : prev);
  93. } catch {}
  94. }, 2000);
  95. return () => { if (pollRef.current) clearInterval(pollRef.current); };
  96. }, [token, assetId, isTranscoding]);
  97. // Load asset + comments
  98. const loadData = useCallback(async () => {
  99. if (!token) return;
  100. try {
  101. const [{ asset: a }, { comments: c }] = await Promise.all([
  102. assetsApi.get(token, assetId),
  103. commentsApi.list(token, assetId),
  104. ]);
  105. setAsset(a);
  106. setComments(c);
  107. } catch {
  108. router.push('/projects');
  109. } finally {
  110. setLoading(false);
  111. }
  112. }, [token, assetId, router]);
  113. useEffect(() => { loadData(); }, [loadData]);
  114. // ── Panel resize ─────────────────────────────────────────────────────────
  115. const handleMouseMove = useCallback((e: MouseEvent) => {
  116. if (!isDraggingRef.current || !resizeStartRef.current) return;
  117. const dx = e.clientX - resizeStartRef.current.x;
  118. setPanelWidth(Math.max(280, Math.min(600, resizeStartRef.current.w + dx)));
  119. }, []);
  120. const handleMouseUp = useCallback(() => {
  121. isDraggingRef.current = false;
  122. resizeStartRef.current = null;
  123. document.body.style.userSelect = '';
  124. document.body.style.cursor = '';
  125. }, []);
  126. useEffect(() => {
  127. window.addEventListener('mousemove', handleMouseMove);
  128. window.addEventListener('mouseup', handleMouseUp);
  129. return () => {
  130. window.removeEventListener('mousemove', handleMouseMove);
  131. window.removeEventListener('mouseup', handleMouseUp);
  132. };
  133. }, [handleMouseMove, handleMouseUp]);
  134. const handleResizeStart = (e: React.MouseEvent) => {
  135. e.preventDefault();
  136. isDraggingRef.current = true;
  137. resizeStartRef.current = { x: e.clientX, w: panelWidth };
  138. document.body.style.userSelect = 'none';
  139. document.body.style.cursor = 'col-resize';
  140. };
  141. // ── Comment actions ───────────────────────────────────────────────────────
  142. const handleAddComment = async (content: string, timestamp?: number, annotations?: AnnotationData[]) => {
  143. if (!token || !content.trim()) return;
  144. setSubmitting(true);
  145. try {
  146. const { comment } = await commentsApi.create(token, assetId, {
  147. content: content.trim(),
  148. timestamp,
  149. annotations,
  150. parentId: replyTo?.id,
  151. });
  152. if (replyTo) {
  153. setComments(prev => prev.map(c =>
  154. c.id === replyTo.id
  155. ? { ...c, replies: [...(c.replies ?? []), comment] }
  156. : c
  157. ));
  158. } else {
  159. setComments(prev => [...prev, comment]);
  160. }
  161. setNewComment('');
  162. setPendingStrokes([]);
  163. setReplyTo(null);
  164. } catch (err) {
  165. alert(err instanceof Error ? err.message : 'Failed to add comment');
  166. } finally {
  167. setSubmitting(false);
  168. }
  169. };
  170. const handleResolve = async (commentId: string, action: 'approve' | 'reject') => {
  171. if (!token) return;
  172. try {
  173. const { comment } = await commentsApi.resolve(token, commentId, action);
  174. setComments(prev => prev.map(c => c.id === commentId ? comment : c));
  175. } catch (err) {
  176. alert(err instanceof Error ? err.message : 'Failed to update comment');
  177. }
  178. };
  179. const handleRequestResolve = async (commentId: string) => {
  180. if (!token) return;
  181. try {
  182. const { comment } = await commentsApi.requestResolve(token, commentId);
  183. setComments(prev => prev.map(c => c.id === commentId ? comment : c));
  184. } catch (err) {
  185. alert(err instanceof Error ? err.message : 'Failed to request resolve');
  186. }
  187. };
  188. const handleDeleteComment = async (commentId: string) => {
  189. if (!token) return;
  190. if (!confirm('Delete this comment?')) return;
  191. try {
  192. await commentsApi.delete(token, commentId);
  193. setComments(prev => prev
  194. .filter(c => c.id !== commentId)
  195. .map(c => ({ ...c, replies: c.replies?.filter(r => r.id !== commentId) }))
  196. );
  197. } catch {
  198. alert('Failed to delete comment');
  199. }
  200. };
  201. // ── Annotation actions ─────────────────────────────────────────────────────
  202. // User clicks "Add annotation" on a comment — enter draw mode, annotate at current time
  203. const handleAddAnnotationClick = (comment: Comment) => {
  204. const existingCount = comment.annotations?.length ?? 0;
  205. if (existingCount >= MAX_ANNOTATIONS) {
  206. alert(`Maximum ${MAX_ANNOTATIONS} annotations per comment.`);
  207. return;
  208. }
  209. setPendingStrokes([]);
  210. setAnnotatingComment(comment);
  211. setDrawMode(true);
  212. };
  213. // Each completed stroke is added to pendingStrokes
  214. const handleStrokeComplete = (stroke: AnnotationData) => {
  215. setPendingStrokes(prev => {
  216. const next = [...prev, stroke];
  217. if (next.length >= MAX_ANNOTATIONS) {
  218. setDrawMode(false);
  219. }
  220. return next;
  221. });
  222. };
  223. // Save pending strokes as annotation on the parent comment (no separate reply)
  224. const handleSaveAnnotations = () => {
  225. const strokes = pendingStrokesRef.current;
  226. const parent = annotatingCommentRef.current;
  227. if (!token || !parent || strokes.length === 0) {
  228. setPendingStrokes([]);
  229. setDrawMode(false);
  230. setAnnotatingComment(null);
  231. return;
  232. }
  233. setSubmitting(true);
  234. setPendingStrokes([]);
  235. setDrawMode(false);
  236. setAnnotatingComment(null);
  237. commentsApi.updateAnnotations(token, parent.id, strokes).then(({ comment }) => {
  238. setComments(prev => prev.map(c => c.id === parent.id ? comment : c));
  239. }).catch(err => alert(err instanceof Error ? err.message : 'Failed to save annotation')).finally(() => setSubmitting(false));
  240. };
  241. // Discard pending strokes
  242. const handleUndoAnnotations = () => {
  243. setPendingStrokes([]);
  244. setDrawMode(false);
  245. setAnnotatingComment(null);
  246. };
  247. // Delete a single annotation from a comment (owner only)
  248. const handleDeleteAnnotation = async (commentId: string, remainingAnnotations: AnnotationData[]) => {
  249. if (!token) return;
  250. try {
  251. const { comment } = await commentsApi.updateAnnotations(token, commentId, remainingAnnotations);
  252. setComments(prev => prev.map(c => c.id === commentId ? comment : c));
  253. } catch {
  254. alert('Failed to delete annotation');
  255. }
  256. };
  257. const handleStatusUpdate = async (status: string) => {
  258. if (!token) return;
  259. setUpdatingStatus(true);
  260. try {
  261. const { asset: updated } = await assetsApi.updateStatus(token, assetId, status);
  262. setAsset(prev => prev ? { ...prev, status: updated.status } : prev);
  263. setShowApproval(false);
  264. } catch {
  265. alert('Failed to update status');
  266. } finally {
  267. setUpdatingStatus(false);
  268. }
  269. };
  270. const handleTimeUpdate = useCallback((time: number) => {
  271. setCurrentTime(time);
  272. }, []);
  273. const handleCommentSeek = useCallback((comment: Comment) => {
  274. const time = comment.timestamp ?? 0;
  275. setCurrentTime(time);
  276. const videoEl = document.querySelector('video') as HTMLVideoElement | null;
  277. if (videoEl) {
  278. videoEl.pause();
  279. videoEl.currentTime = time;
  280. }
  281. }, []);
  282. const status = asset?.status ?? 'PENDING_REVIEW';
  283. const statusCfg = STATUS_CONFIG[status];
  284. const transcodeCfg = asset ? TRANSCODE_CONFIG[asset.transcodeStatus] : null;
  285. const videoUrl = asset?.hlsPath
  286. ? `${API_BASE}/uploads${asset.hlsPath}`
  287. : asset
  288. ? `${API_BASE}/uploads/${asset.filePath}`
  289. : '';
  290. const allComments = comments.flatMap(c => [c, ...(c.replies ?? [])]);
  291. const visibleComments = showResolved ? comments : comments.filter(c => !c.resolved);
  292. // Only main comments (not replies) have annotations that should show on the video
  293. const visibleAnnotations = visibleComments.flatMap(c =>
  294. (c.annotations ?? []).map(ann => ({ annotation: ann, timestamp: c.timestamp ?? 0 }))
  295. );
  296. if (loading) {
  297. return (
  298. <div className="h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
  299. <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
  300. <div className="w-5 h-5 rounded-full animate-spin"
  301. style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
  302. <span className="text-sm">Loading review…</span>
  303. </div>
  304. </div>
  305. );
  306. }
  307. if (!asset) return null;
  308. return (
  309. <div className="h-screen flex flex-col overflow-hidden" style={{ background: 'var(--bg)' }}>
  310. {/* ── Top bar ──────────────────────────────────────────── */}
  311. <header className="h-12 flex items-center px-4 gap-3 shrink-0"
  312. style={{ background: 'rgba(10,11,20,0.95)', borderBottom: '1px solid rgba(255,255,255,0.06)', zIndex: 50 }}>
  313. <button
  314. onClick={() => router.push(`/projects/${asset.projectId}`)}
  315. className="flex items-center gap-1.5 text-xs transition-colors shrink-0"
  316. style={{ color: 'var(--text-muted)' }}
  317. >
  318. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  319. <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
  320. </svg>
  321. <span className="hidden sm:inline">Back</span>
  322. </button>
  323. <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
  324. <div className="flex-1 min-w-0">
  325. <h1 className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>{asset.title}</h1>
  326. </div>
  327. <span className="text-xs hidden sm:inline shrink-0" style={{ color: 'var(--text-subtle)' }}>
  328. {asset.project?.name}
  329. </span>
  330. <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
  331. {/* Download */}
  332. <a
  333. href={`${API_BASE}/uploads/${asset.filePath}`}
  334. download={asset.filename}
  335. className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md transition-all shrink-0"
  336. style={{ color: '#60A5FA', background: 'rgba(96,165,250,0.08)' }}
  337. title="Download original video"
  338. >
  339. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  340. <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
  341. </svg>
  342. <span className="hidden sm:inline">Download</span>
  343. </a>
  344. <div className="w-px h-5 shrink-0" style={{ background: 'rgba(255,255,255,0.08)' }} />
  345. {/* Status selector */}
  346. <div className="relative shrink-0">
  347. <button
  348. onClick={() => setShowApproval(v => !v)}
  349. className="flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-md transition-all"
  350. style={{ background: statusCfg.bgClass.replace('badge-', 'rgba(').replace('warning', '245,158,11,0.15)').replace('success', '34,197,94,0.15)').replace('danger', '239,68,68,0.15)'), color: statusCfg.colorClass }}
  351. >
  352. <span className={`status-dot ${statusCfg.dotClass}`} />
  353. <span className="hidden sm:inline">{statusCfg.label}</span>
  354. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  355. <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
  356. </svg>
  357. </button>
  358. {showApproval && (
  359. <>
  360. <div className="fixed inset-0 z-40" onClick={() => setShowApproval(false)} />
  361. <div className="absolute right-0 top-full mt-2 z-50 rounded-xl overflow-hidden"
  362. style={{ background: '#1E2030', border: '1px solid rgba(255,255,255,0.10)', boxShadow: 'var(--shadow-panel)', minWidth: '200px' }}>
  363. {Object.entries(STATUS_CONFIG).map(([key, cfg]) => (
  364. <button
  365. key={key}
  366. onClick={() => handleStatusUpdate(key)}
  367. disabled={updatingStatus}
  368. className="w-full flex items-center gap-2.5 px-4 py-2.5 text-xs transition-colors hover:bg-white/5"
  369. style={{ color: key === status ? cfg.colorClass : 'var(--text)' }}
  370. >
  371. <span className={`status-dot ${cfg.dotClass}`} />
  372. <span className="flex-1 text-left">{cfg.label}</span>
  373. {key === status && (
  374. <svg className="w-3.5 h-3.5" style={{ color: '#6366F1' }} fill="currentColor" viewBox="0 0 20 20">
  375. <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
  376. </svg>
  377. )}
  378. </button>
  379. ))}
  380. </div>
  381. </>
  382. )}
  383. </div>
  384. </header>
  385. {/* ── Body ───────────────────────────────────────────── */}
  386. {/* Landscape: side-by-side | Portrait: stacked (video top, comments bottom) */}
  387. <div
  388. className="flex flex-1 overflow-hidden"
  389. style={isPortrait
  390. ? { flexDirection: 'column', overflowY: 'auto' }
  391. : { flexDirection: 'row' }}
  392. >
  393. {/* Video area */}
  394. <div
  395. className="overflow-y-auto p-3 sm:p-4 flex flex-col gap-3 min-w-0"
  396. style={isPortrait
  397. ? { flex: 'none', width: '100%', minHeight: '60vh' }
  398. : { flex: 1, overflowY: 'auto' }}
  399. >
  400. <VideoPlayer
  401. src={videoUrl}
  402. mimeType={asset.mimeType}
  403. fps={fps}
  404. comments={allComments}
  405. visibleAnnotations={visibleAnnotations}
  406. drawMode={drawMode}
  407. drawTool={drawTool}
  408. drawColor={drawColor}
  409. onDrawModeChange={setDrawMode}
  410. onDrawToolChange={setDrawTool}
  411. onDrawColorChange={setDrawColor}
  412. pendingStrokes={pendingStrokes}
  413. onStrokeComplete={handleStrokeComplete}
  414. onTimeUpdate={handleTimeUpdate}
  415. onCommentClick={handleCommentSeek}
  416. />
  417. {/* Transcode status overlay — shown when video is not ready */}
  418. {transcodeCfg && asset.transcodeStatus !== 'COMPLETED' && (
  419. <div className="mt-3 rounded-xl p-4 flex items-center gap-4"
  420. style={{ background: transcodeCfg.bg, border: `1px solid ${transcodeCfg.color}30` }}>
  421. {transcodeCfg.spinner ? (
  422. <div className="w-8 h-8 rounded-full animate-spin shrink-0"
  423. style={{ borderColor: transcodeCfg.color, borderTopColor: 'transparent', borderWidth: '2.5px' }} />
  424. ) : asset.transcodeStatus === 'FAILED' ? (
  425. <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
  426. style={{ background: 'rgba(248,113,113,0.15)' }}>
  427. <svg className="w-4 h-4" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  428. <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
  429. </svg>
  430. </div>
  431. ) : (
  432. <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
  433. style={{ background: 'rgba(251,191,36,0.15)' }}>
  434. <svg className="w-4 h-4" style={{ color: '#FBBF24' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  435. <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
  436. </svg>
  437. </div>
  438. )}
  439. <div className="flex-1 min-w-0">
  440. <div className="flex items-center gap-2 mb-1">
  441. <span className="text-sm font-medium" style={{ color: transcodeCfg.color }}>
  442. {transcodeCfg.label}
  443. </span>
  444. {asset.transcodeStatus === 'PROCESSING' && asset.transcodeProgress > 0 && (
  445. <span className="text-xs font-mono" style={{ color: transcodeCfg.color }}>
  446. {asset.transcodeProgress}%
  447. </span>
  448. )}
  449. </div>
  450. {asset.transcodeStatus === 'PROCESSING' && (
  451. <div className="w-full h-1 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.08)' }}>
  452. <div
  453. className="h-full rounded-full transition-all duration-500"
  454. style={{ width: `${asset.transcodeProgress}%`, background: transcodeCfg.color }}
  455. />
  456. </div>
  457. )}
  458. {asset.transcodeStatus === 'FAILED' && asset.transcodeError && (
  459. <p className="text-xs mt-1" style={{ color: '#F87171' }}>
  460. {asset.transcodeError}
  461. </p>
  462. )}
  463. {asset.transcodeStatus === 'UNSUPPORTED_CODEC' && (
  464. <p className="text-xs mt-1" style={{ color: '#FB923C' }}>
  465. {asset.codec ? `Source codec "${asset.codec.toUpperCase()}" — will re-encode to H.264/AAC` : 'Re-encoding to browser-compatible format…'}
  466. </p>
  467. )}
  468. {asset.transcodeStatus === 'PROCESSING' && asset.codec && (
  469. <p className="text-xs mt-1" style={{ color: '#94A3B8' }}>
  470. Converting from {asset.codec.toUpperCase()} → H.264/AAC
  471. </p>
  472. )}
  473. {asset.transcodeStatus === 'UPLOADING' && (
  474. <p className="text-xs mt-1" style={{ color: '#94A3B8' }}>
  475. Video uploaded — queued for processing
  476. </p>
  477. )}
  478. </div>
  479. </div>
  480. )}
  481. {/* Keyboard shortcuts */}
  482. <div className="flex flex-wrap gap-3 text-xs shrink-0" style={{ color: 'var(--text-subtle)' }}>
  483. <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Space</kbd> play/pause</span>
  484. <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>←</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>→</kbd> seek ±5s</span>
  485. <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>U</kbd><kbd className="px-1.5 py-0.5 rounded text-[10px] ml-0.5" style={{ background: 'rgba(255,255,255,0.06)' }}>I</kbd> frame</span>
  486. <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>C</kbd> draw mode</span>
  487. <span><kbd className="px-1.5 py-0.5 rounded text-[10px]" style={{ background: 'rgba(255,255,255,0.06)' }}>Esc</kbd> exit draw</span>
  488. <span className="font-mono text-[11px]">{formatTimecode(currentTime, fps)}</span>
  489. </div>
  490. </div>
  491. {/* Resize handle — only shown in landscape */}
  492. {!isPortrait && (
  493. <div className="resize-handle" onMouseDown={handleResizeStart} style={{ width: '4px' }} />
  494. )}
  495. {/* ── Comment panel ─────────────────────────────────── */}
  496. <div
  497. ref={panelRef}
  498. className="flex flex-col overflow-hidden shrink-0"
  499. style={isPortrait
  500. ? {
  501. flex: 1,
  502. width: '100%',
  503. minHeight: '40vh',
  504. background: 'rgba(10,11,20,0.98)',
  505. borderTop: '1px solid rgba(255,255,255,0.06)',
  506. }
  507. : {
  508. width: panelWidth,
  509. background: 'rgba(10,11,20,0.98)',
  510. borderLeft: '1px solid rgba(255,255,255,0.06)',
  511. }}
  512. >
  513. {/* Panel header */}
  514. <div className="px-4 py-3 flex items-center justify-between shrink-0"
  515. style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
  516. <div className="flex items-center gap-2">
  517. <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>Comments</h2>
  518. <span className="text-xs px-1.5 py-0.5 rounded-full"
  519. style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}>
  520. {comments.length}
  521. </span>
  522. </div>
  523. <div className="flex items-center gap-2">
  524. <span className="font-mono text-xs" style={{ color: '#818CF8' }}>
  525. {formatTimecode(currentTime, fps)}
  526. </span>
  527. <button
  528. onClick={() => setShowResolved(v => !v)}
  529. className={`text-xs px-2 py-0.5 rounded-md transition-colors ${showResolved ? 'bg-indigo-600 text-white' : ''}`}
  530. style={!showResolved ? { background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' } : {}}
  531. >
  532. {showResolved ? 'Hide resolved' : 'Show resolved'}
  533. </button>
  534. </div>
  535. </div>
  536. {/* Drawing mode banner */}
  537. {drawMode && (
  538. <div className="px-4 py-2 shrink-0 flex items-center gap-2"
  539. style={{ background: 'rgba(59,130,246,0.12)', borderBottom: '1px solid rgba(59,130,246,0.2)' }}>
  540. <svg className="w-4 h-4 shrink-0" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  541. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  542. </svg>
  543. <span className="text-xs flex-1" style={{ color: '#818CF8' }}>
  544. {annotatingComment
  545. ? `Drawing annotation on "${annotatingComment.user?.name}" — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`
  546. : `Drawing on video — ${pendingStrokes.length}/${MAX_ANNOTATIONS} strokes`}
  547. </span>
  548. <div className="flex items-center gap-1.5">
  549. <button
  550. onClick={handleUndoAnnotations}
  551. className="text-xs px-2 py-0.5 rounded transition-colors"
  552. style={{ background: 'rgba(239,68,68,0.15)', color: '#FCA5A5' }}
  553. >
  554. Undo all
  555. </button>
  556. <button
  557. onClick={handleSaveAnnotations}
  558. disabled={submitting || pendingStrokes.length === 0}
  559. className="text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-40"
  560. style={{ background: 'rgba(34,197,94,0.15)', color: '#86EFAC' }}
  561. >
  562. {submitting ? 'Saving…' : 'Save'}
  563. </button>
  564. </div>
  565. </div>
  566. )}
  567. {/* Comment list */}
  568. <div className="flex-1 overflow-y-auto scroll-area">
  569. {visibleComments.length === 0 ? (
  570. <div className="flex flex-col items-center justify-center py-16 px-4 text-center">
  571. <div className="w-12 h-12 rounded-2xl flex items-center justify-center mb-3"
  572. style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
  573. <svg className="w-6 h-6" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  574. <path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
  575. </svg>
  576. </div>
  577. <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>No comments yet</p>
  578. <p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
  579. Add a comment below or click <strong>Add annotation</strong> on an existing comment
  580. </p>
  581. </div>
  582. ) : (
  583. <div>
  584. {visibleComments.map(comment => (
  585. <CommentItem
  586. key={comment.id}
  587. comment={comment}
  588. currentUserId={user?.id ?? ''}
  589. fps={fps}
  590. canComment={canComment}
  591. isProjectAdmin={isProjectAdmin}
  592. onTimestampClick={handleCommentSeek}
  593. onReply={() => { setReplyTo(comment); }}
  594. onResolve={(action) => handleResolve(comment.id, action)}
  595. onRequestResolve={() => handleRequestResolve(comment.id)}
  596. onDeleteSelf={() => handleDeleteComment(comment.id)}
  597. onDelete={(id) => handleDeleteComment(id)}
  598. onAddAnnotation={() => handleAddAnnotationClick(comment)}
  599. onDeleteAnnotation={(anns) => handleDeleteAnnotation(comment.id, anns)}
  600. />
  601. ))}
  602. </div>
  603. )}
  604. </div>
  605. {/* New comment / reply input */}
  606. <div className="shrink-0 p-3"
  607. style={{ borderTop: '1px solid rgba(255,255,255,0.06)', background: 'rgba(10,11,20,0.80)' }}>
  608. {replyTo && (
  609. <div className="flex items-center gap-2 mb-2 text-xs" style={{ color: 'var(--text-muted)' }}>
  610. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  611. <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
  612. </svg>
  613. Replying to {replyTo.user?.name}
  614. <button onClick={() => setReplyTo(null)} className="ml-auto" style={{ color: 'var(--text-subtle)' }}>
  615. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  616. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  617. </svg>
  618. </button>
  619. </div>
  620. )}
  621. {/* Pending strokes indicator */}
  622. {pendingStrokes.length > 0 && (
  623. <div className="flex items-center gap-2 mb-2 text-xs" style={{ color: '#818CF8' }}>
  624. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  625. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  626. </svg>
  627. {pendingStrokes.length} stroke{pendingStrokes.length !== 1 ? 's' : ''} ready
  628. {annotatingComment ? ` → annotation on "${annotatingComment.user?.name}"` : ' → will be saved as new comment'}
  629. <button onClick={handleUndoAnnotations} className="ml-auto text-xs" style={{ color: '#FCA5A5' }}>Undo</button>
  630. </div>
  631. )}
  632. <form
  633. onSubmit={e => {
  634. e.preventDefault();
  635. if (newComment.trim() || pendingStrokes.length > 0) {
  636. handleAddComment(newComment, currentTime, pendingStrokes.length > 0 ? pendingStrokes : undefined);
  637. }
  638. }}
  639. className="flex gap-2"
  640. >
  641. <Avatar name={user?.name ?? 'U'} size="sm" />
  642. <div className="flex-1 flex gap-2">
  643. <textarea
  644. className="input flex-1"
  645. value={newComment}
  646. onChange={e => setNewComment(e.target.value)}
  647. placeholder={replyTo ? 'Write a reply…' : 'Add a comment…'}
  648. rows={1}
  649. style={{ resize: 'none', overflow: 'hidden' }}
  650. onKeyDown={e => {
  651. if (e.key === 'Enter' && !e.shiftKey) {
  652. e.preventDefault();
  653. if (newComment.trim() || pendingStrokes.length > 0) {
  654. handleAddComment(newComment, currentTime, pendingStrokes.length > 0 ? pendingStrokes : undefined);
  655. }
  656. }
  657. }}
  658. />
  659. <button
  660. type="submit"
  661. disabled={submitting || (!newComment.trim() && pendingStrokes.length === 0)}
  662. className="btn btn-primary btn-sm px-3"
  663. >
  664. {submitting ? (
  665. <div className="w-3.5 h-3.5 rounded-full animate-spin"
  666. style={{ borderColor: '#fff', borderTopColor: 'transparent' }} />
  667. ) : (
  668. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  669. <path strokeLinecap="round" strokeLinejoin="round" d="M6 12h12M6 12l4-4M6 12l4 4" />
  670. </svg>
  671. )}
  672. </button>
  673. </div>
  674. </form>
  675. </div>
  676. </div>
  677. </div>
  678. </div>
  679. );
  680. }
  681. // ── CommentItem ─────────────────────────────────────────────────────────────
  682. function CommentItem({
  683. comment,
  684. currentUserId,
  685. fps,
  686. canComment,
  687. isProjectAdmin,
  688. onTimestampClick,
  689. onReply,
  690. onResolve,
  691. onRequestResolve,
  692. onDeleteSelf,
  693. onDelete,
  694. onAddAnnotation,
  695. onDeleteAnnotation,
  696. }: {
  697. comment: Comment;
  698. currentUserId: string;
  699. fps: number;
  700. canComment: boolean | undefined;
  701. isProjectAdmin: boolean;
  702. onTimestampClick: (c: Comment) => void;
  703. onReply: () => void;
  704. onResolve: (action: 'approve' | 'reject') => void;
  705. onRequestResolve: () => void;
  706. onDeleteSelf: () => void;
  707. onDelete: (id: string) => void;
  708. onAddAnnotation: () => void;
  709. onDeleteAnnotation: (annotations: AnnotationData[]) => void;
  710. }) {
  711. const isOwner = comment.userId === currentUserId;
  712. const isCommentAuthor = comment.userId === currentUserId;
  713. const name = comment.user?.name ?? 'Unknown';
  714. const isReply = !!comment.parentId;
  715. const annotations = comment.annotations ?? [];
  716. const canAddMore = annotations.length < MAX_ANNOTATIONS;
  717. // Resolve state machine
  718. const isResolved = comment.resolveStatus === 'RESOLVED';
  719. const isPending = comment.resolveStatus === 'PENDING_APPROVAL';
  720. const canApprove = isCommentAuthor || isProjectAdmin;
  721. const canRequest = canComment && !isResolved && !isPending && !isCommentAuthor;
  722. const canReopen = isResolved && canApprove;
  723. return (
  724. <div
  725. className="p-4 animate-fade-in"
  726. style={{ opacity: isResolved ? 0.55 : 1, paddingLeft: isReply ? '2.5rem' : undefined }}
  727. >
  728. <div className="flex gap-2.5">
  729. <Avatar name={name} size="sm" />
  730. <div className="flex-1 min-w-0">
  731. {/* Meta row */}
  732. <div className="flex items-center gap-2 mb-1 flex-wrap">
  733. <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>{name}</span>
  734. {comment.timestamp != null && (
  735. <button
  736. onClick={() => onTimestampClick(comment)}
  737. className="text-xs px-1.5 py-0.5 rounded font-mono transition-colors hover:bg-indigo-600/20"
  738. style={{ background: 'rgba(99,102,241,0.10)', color: '#818CF8', fontSize: '11px' }}
  739. >
  740. {formatTimecode(comment.timestamp, fps)}
  741. </button>
  742. )}
  743. {isPending && (
  744. <span className="text-xs px-1.5 py-0.5 rounded"
  745. style={{ background: 'rgba(251,191,36,0.12)', color: '#FCD34D' }}>
  746. Pending approval
  747. </span>
  748. )}
  749. {isResolved && (
  750. <span className="text-xs px-1.5 py-0.5 rounded"
  751. style={{ background: 'rgba(34,197,94,0.10)', color: '#86EFAC' }}>
  752. Approved
  753. </span>
  754. )}
  755. {isResolved && comment.resolvedBy && (
  756. <span className="text-xs" style={{ color: 'var(--text-subtle)' }}>
  757. by {comment.resolvedBy.name}
  758. </span>
  759. )}
  760. <span className="text-xs ml-auto" style={{ color: 'var(--text-subtle)' }}>
  761. {new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
  762. </span>
  763. </div>
  764. {/* Annotation preview badges */}
  765. {annotations.length > 0 && (
  766. <div className="flex flex-wrap gap-1 mb-2">
  767. {annotations.map((ann, i) => (
  768. <div
  769. key={i}
  770. className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded"
  771. style={{ background: `${ann.color}20`, color: ann.color, border: `1px solid ${ann.color}40` }}
  772. >
  773. <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  774. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  775. </svg>
  776. {ann.type}
  777. {isOwner && (
  778. <button
  779. onClick={() => {
  780. const remaining = annotations.filter((_, j) => j !== i);
  781. onDeleteAnnotation(remaining);
  782. }}
  783. className="ml-0.5 hover:opacity-70 transition-opacity"
  784. title="Delete this annotation"
  785. >
  786. <svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  787. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  788. </svg>
  789. </button>
  790. )}
  791. </div>
  792. ))}
  793. </div>
  794. )}
  795. {/* Content */}
  796. <p className="text-sm leading-relaxed mb-2" style={{ color: 'var(--text-muted)' }}>
  797. {comment.content}
  798. </p>
  799. {/* Actions */}
  800. <div className="flex items-center gap-1">
  801. {!isReply && (
  802. <button
  803. onClick={onAddAnnotation}
  804. disabled={!canAddMore}
  805. className="text-xs px-2 py-1 rounded-md transition-colors disabled:opacity-30"
  806. style={{ color: '#818CF8' }}
  807. title={canAddMore ? `Add annotation (${annotations.length}/${MAX_ANNOTATIONS})` : `Max ${MAX_ANNOTATIONS} annotations reached`}
  808. >
  809. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  810. <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  811. </svg>
  812. </button>
  813. )}
  814. {!isReply && (
  815. <button
  816. onClick={onReply}
  817. className="text-xs px-2 py-1 rounded-md transition-colors"
  818. style={{ color: 'var(--text-muted)' }}
  819. title="Reply"
  820. >
  821. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  822. <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
  823. </svg>
  824. </button>
  825. )}
  826. {/* Resolve / approval workflow buttons */}
  827. {!isReply && !isResolved && !isPending && (
  828. <>
  829. {canRequest ? (
  830. <button
  831. onClick={onRequestResolve}
  832. className="text-xs px-2 py-1 rounded-md transition-colors"
  833. style={{ color: '#6366F1' }}
  834. title="Request resolve approval"
  835. >
  836. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  837. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  838. </svg>
  839. Request resolve
  840. </button>
  841. ) : (
  842. <span
  843. className="text-xs px-2 py-1 opacity-30"
  844. style={{ color: '#6366F1' }}
  845. title={!canComment ? 'Viewers cannot request resolve' : isCommentAuthor ? 'Cannot resolve your own comment' : undefined}
  846. >
  847. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  848. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  849. </svg>
  850. Request resolve
  851. </span>
  852. )}
  853. </>
  854. )}
  855. {isPending && canApprove && !isReply && (
  856. <>
  857. <button
  858. onClick={() => onResolve('approve')}
  859. className="text-xs px-2 py-1 rounded-md transition-colors"
  860. style={{ color: '#86EFAC' }}
  861. title={`Approve (by ${comment.requestedBy?.name ?? 'someone'})`}
  862. >
  863. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  864. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  865. </svg>
  866. Approve
  867. </button>
  868. <button
  869. onClick={() => onResolve('reject')}
  870. className="text-xs px-2 py-1 rounded-md transition-colors"
  871. style={{ color: '#FCA5A5' }}
  872. title="Reject resolve request"
  873. >
  874. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  875. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  876. </svg>
  877. Reject
  878. </button>
  879. </>
  880. )}
  881. {isPending && !canApprove && !isReply && (
  882. <span className="text-xs px-2 py-1 opacity-40" style={{ color: '#FCD34D' }} title="Awaiting approval">
  883. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  884. <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
  885. </svg>
  886. Awaiting approval
  887. </span>
  888. )}
  889. {canReopen && !isReply && (
  890. <button
  891. onClick={() => onResolve('reject')}
  892. className="text-xs px-2 py-1 rounded-md transition-colors"
  893. style={{ color: '#86EFAC' }}
  894. title="Reopen comment"
  895. >
  896. <svg className="w-3.5 h-3.5 inline-block mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  897. <path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
  898. </svg>
  899. Reopen
  900. </button>
  901. )}
  902. {isOwner && (
  903. <button
  904. onClick={onDeleteSelf}
  905. className="text-xs px-2 py-1 rounded-md transition-colors"
  906. style={{ color: 'var(--text-subtle)' }}
  907. title="Delete comment"
  908. >
  909. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  910. <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
  911. </svg>
  912. </button>
  913. )}
  914. </div>
  915. {/* Replies */}
  916. {comment.replies && comment.replies.length > 0 && (
  917. <div className="mt-3 space-y-3">
  918. {comment.replies.map(reply => (
  919. <ReplyItem
  920. key={reply.id}
  921. comment={reply}
  922. isOwner={reply.userId === currentUserId}
  923. onDelete={() => onDelete(reply.id)}
  924. />
  925. ))}
  926. </div>
  927. )}
  928. </div>
  929. </div>
  930. </div>
  931. );
  932. }
  933. // ── ReplyItem ──────────────────────────────────────────────────────────────
  934. // Replies have no resolve, no annotation, no timestamp — just content + delete
  935. function ReplyItem({
  936. comment,
  937. isOwner,
  938. onDelete,
  939. }: {
  940. comment: Comment;
  941. isOwner: boolean;
  942. onDelete: (id: string) => void;
  943. }) {
  944. return (
  945. <div className="flex gap-2.5 animate-fade-in">
  946. <Avatar name={comment.user?.name ?? 'U'} size="sm" />
  947. <div className="flex-1 min-w-0">
  948. <div className="flex items-center gap-2 mb-0.5">
  949. <span className="text-xs font-medium" style={{ color: 'var(--text)' }}>
  950. {comment.user?.name ?? 'Unknown'}
  951. </span>
  952. <span className="text-xs ml-auto" style={{ color: 'var(--text-subtle)' }}>
  953. {new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
  954. </span>
  955. </div>
  956. <p className="text-sm leading-relaxed" style={{ color: 'var(--text-muted)' }}>
  957. {comment.content}
  958. </p>
  959. {isOwner && (
  960. <button
  961. onClick={() => onDelete(comment.id)}
  962. className="text-xs mt-1 transition-colors"
  963. style={{ color: 'var(--text-subtle)' }}
  964. title="Delete reply"
  965. >
  966. Delete
  967. </button>
  968. )}
  969. </div>
  970. </div>
  971. );
  972. }